剖析 SharedPreference apply 引起的 ANR 问题
项目中 ANR 率居高不下,从统计上来看排在前面的有几个都是 SharedPreference(以下简称 SP)引起的。接下来我们抽丝剥茧的来分析其产生原因及如何解决。
crash 堆栈信息如下。从 crash 收集平台上来看,有几个类似的堆栈信息。唯一的区别就是 ActivityThread 的入口方法。除了 ActivityThread 的 handleSleeping 方法之外,还有 handleServiceArgs、handleStopService、handleStopActivity。
ActivityThread 的这几个方法是 Activity 或 Service 的生命周期变化的时候调用的。从堆栈信息来看,组件生命周期变化,导致调用 QueueWork 中的队列处于等待状态,等待超时则发生 ANR。那么 QueuedWork 的工作机制是什么样的呢,我们从源码入手来进行分析。
SP 的 apply 到底做了什么
首先从问题的源头开始,SP 的 apply 方法。
public void apply() {
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
}
};
QueuedWork.add(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
public void run() {
awaitCommit.run();
QueuedWork.remove(awaitCommit);
}
};
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
// Okay to notify the listeners before it's hit disk
// because the listeners should always get the same
// SharedPreferences instance back, which has the
// changes reflected in memory.
notifyListeners(mcr);
}
apply 方法,首先创建了一个 awaitCommit 的 Runnable,然后加入到 QueuedWork 中,awaitCommit 中包含了一个等待锁,需要在其它地方释放。我们在上面看到的 QueuedWork.waitToFinish() 其实就是等待这个队列中的 awaitCommit 全部释放。
然后通过 SharedPreferencesImpl.this.enqueueDiskWrite 创建了一个任务来执行真正的 SP 持久化。
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
final Runnable writeToDiskRunnable = new Runnable() {
public void run() {
synchronized (mWritingToDiskLock) {
writeToFile(mcr);
}
synchronized (SharedPreferencesImpl.this) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};
final boolean isFromSyncCommit = (postWriteRunnable == null);
// Typical #commit() path with fewer allocations, doing a write on
// the current thread.
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (SharedPreferencesImpl.this) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}
其实无论是 SP 的 commit 还是 apply 最终都会调用 enqueueDiskWrite 方法,区别是 commit 方法调用传递的第二个参数为 null。此方法内部也是根据第二个参数来区分 commit 和 apply 的,如果是 commit 则会同步的执行 writeToFile,apply 则会将 writeToFile 加入到一个任务队列中异步的执行,从这里也可以看出 commit 和 apply 的真正区别。
writeToFile 执行完成会释放等待锁,之后会回调传递进来的第二个参数 Runnable 的 run 方法,并将 QueuedWork 中的这个等待任务移除。
总结来看,SP 调用 apply 方法,会创建一个等待锁放到 QueuedWork 中,并将真正数据持久化封装成一个任务放到异步队列中执行,任务执行结束会释放锁。Activity onStop 以及 Service 处理 onStop,onStartCommand 时,执行 QueuedWork.waitToFinish() 等待所有的等待锁释放。
如何解决,清空等待队列
从上述分析来看,SP 操作仅仅把 commit 替换为 apply 不是万能的,apply 调用次数过多容易引起 ANR。所有此类 ANR 都是经由 QueuedWork.waitToFinish() 触发的,如果在调用此函数之前,将其中保存的队列手动清空,那么是不是能解决问题呢,答案是肯定的。
Activity 的 onStop,以及 Service 的 onStop 和 onStartCommand 都是通过 ActivityThread 触发的,ActivityThread 中有一个 Handler 变量,我们通过 Hook 拿到此变量,给此 Handler 设置一个 callback,Handler 的 dispatchMessage 中会先处理 callback。
public static void tryHackActivityThreadH() {
try {
if ((Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT)) {
Reflect activityThreadRef = Reflect.on(Class.forName("android.app.ActivityThread")).
call("currentActivityThread");
if (activityThreadRef != null) {
Handler h = activityThreadRef.field("mH", Class.forName("android.app.ActivityThread$H")).<Handler>get();
if (h != null) {
Reflect hRef = Reflect.on(h);
Handler.Callback hCallBack = hRef.field("mCallback", Handler.Callback.class).<Handler.Callback>get();
ActivityThreadHCallBack activityThreadHCallBack = new ActivityThreadHCallBack(h, hCallBack);
hRef.set("mCallback", activityThreadHCallBack);
}
}
}
} catch (Throwable t) {
t.printStackTrace();
// ignore
}
}
在 Callback 中调用队列的清理工作
public class ActivityThreadHCallBack implements Handler.Callback {
private static final int SERVICE_ARGS = 115;
private static final int STOP_SERVICE = 116;
private static final int SLEEPING = 137;
private static final int STOP_ACTIVITY_SHOW = 103;
private static final int STOP_ACTIVITY_HIDE = 104;
private static final int ENTER_ANIMATION_COMPLETE = 149;
@Override
public boolean handleMessage(Message msg) {
final int what = msg.what;
switch (what) {
case SERVICE_ARGS:
SpBlockHelper.beforeSPBlock("SERVICE_ARGS");
break;
case STOP_SERVICE:
SpBlockHelper.beforeSPBlock("STOP_SERVICE");
break;
case SLEEPING:
SpBlockHelper.beforeSPBlock("SLEEPING");
break;
case STOP_ACTIVITY_SHOW:
case STOP_ACTIVITY_HIDE:
SpBlockHelper.beforeSPBlock("STOP_ACTIVITY");
break;
}
return false;
}
}
队列清理需要反射调用 QueuedWork。
public class SpBlockHelper {
static final String TAG = "SpBlockHelper";
static boolean init = false;
static String CLASS_QUEUED_WORK = "android.app.QueuedWork";
static String FIELD_PENDING_FINISHERS = "sPendingWorkFinishers";
static ConcurrentLinkedQueue<Runnable> sPendingWorkFinishers = null;
public static void beforeSPBlock(String tag) {
if (!init) {
getPendingWorkFinishers();
init = true;
}
Logger.d(TAG, "beforeSPBlock " + tag);
if (sPendingWorkFinishers != null) {
sPendingWorkFinishers.clear();
}
}
static void getPendingWorkFinishers() {
try {
Class clazz = Class.forName(CLASS_QUEUED_WORK);
Field field = clazz.getDeclaredField(FIELD_PENDING_FINISHERS);
if (field != null) {
field.setAccessible(true);
sPendingWorkFinishers = (ConcurrentLinkedQueue<Runnable>) field.get(null);
}
} catch (Exception e) {
MiraLogger.e(TAG, "getPendingWorkFinishers", e);
}
}
}
清理等待锁会产生什么问题
SP 无论是 commit 还是 apply 都会产生 ANR,但从 Android 之初到目前 Android8.0,Google 一直没有修复此 bug,我们贸然处理会产生什么问题呢。Google 在 Activity 和 Service 调用 onStop 之前阻塞主线程来处理 SP,我们能猜到的唯一原因是尽可能的保证数据的持久化。因为如果在运行过程中产生了 crash,也会导致 SP 未持久化,持久化本身是 IO 操作,也会失败。我们清理了等待锁队列,会对数据持久化造成什么影响呢,下面我们通过一组实验来验证。
进程启动的时候,产生一个随机数字。用 commit 和 apply 两种方式来存此变量。第二次进程启动,获取以两种方式存取的值并做比较,如果相同表示 apply 持久化成功,如果不相同表示 apply 持久化失败。
实验一:开启等待锁队列的清理。
实验二:关闭等待锁队列的清理。
线上同时开启两个实验,在实验规模相同的情况下,统计 apply 失败率。
实验一,失败率为 1.84%。
实验二,失败率为为 1.79%
可见,apply 机制本身的失败率就比较高,清理等待锁队列对持久化造成的影响不大。
目前头条 app 已经全量开启清理等待锁策略,上线至今没有发现此策略产生的用户反馈。
长按识别二维码,关注今日头条技术团队